Skip to content

feat(ui): smooth streaming of agent message tokens#2685

Open
archievi wants to merge 2 commits into
PostHog:mainfrom
archievi:smooth-token-streaming
Open

feat(ui): smooth streaming of agent message tokens#2685
archievi wants to merge 2 commits into
PostHog:mainfrom
archievi:smooth-token-streaming

Conversation

@archievi

Copy link
Copy Markdown

Problem

Closes #2517

Streamed assistant tokens arrive from the agent in bursty chunks. Because the message component re-renders the full accumulated text on every chunk, the text jumps forward unevenly instead of reading as smooth, steady typing. The issue asks for smooth token streaming (per the Upstash smooth-streaming approach) while keeping the conversation pinned to the bottom as it streams.

Changes

  • Add a useSmoothText hook (packages/ui/src/primitives/hooks/useSmoothText.ts) that decouples the render rate from the token-arrival rate. It reveals the accumulated text a few characters per frame via requestAnimationFrame, so bursts of tokens are eased out at a steady rate (~120 chars/sec).
  • Use it in AgentMessage to render the smoothed text. Copy-to-clipboard still copies the full content, not the partially revealed prefix.

Design notes:

  • Text already present when the component mounts is shown immediately, so re-rendered history and completed messages never replay a typewriter effect. Only growth that happens while mounted is animated.
  • It snaps instantly when the source is replaced (i.e. the new value is not a prefix of what was being revealed) or when the user has prefers-reduced-motion: reduce set.
  • A large lag (e.g. a very long buffered chunk) snaps rather than crawling, so catch-up never feels sluggish.
  • Auto-scroll-to-bottom is preserved: the virtualized list re-pins to the end whenever the rendered height changes, and since the smoothed text grows the height incrementally, the view follows it down naturally (no change needed to the scroll logic).

The reveal rate easing is extracted into a pure nextRevealLength function so it is unit-testable without timers.

How did you test this?

  • Added packages/ui/src/primitives/hooks/useSmoothText.test.ts covering the pure easing function and the hook (mocked requestAnimationFrame/matchMedia): immediate render on mount, gradual reveal of an appended burst, full catch-up, snap-on-replace, and reduced-motion. Verified the gradual-reveal and snap-on-replace tests fail when the hook is reduced to a pass-through, confirming they guard the behavior.
  • pnpm --filter @posthog/ui test -> 82 files, 737 tests pass.
  • pnpm --filter @posthog/ui typecheck (tsc --noEmit) passes; the pre-commit hook's full pnpm typecheck also passes.
  • biome check passes on the changed files.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Streamed assistant tokens arrive in bursty chunks, so the message text
jumped forward unevenly as it rendered. Add a useSmoothText hook that
reveals the accumulated text at a steady character rate using
requestAnimationFrame, so bursts read as smooth typing.

- Text present on mount renders immediately, so history and completed
  messages never replay a typewriter effect; only growth while mounted
  is animated.
- Snaps instantly when the source is replaced (not a prefix) or when the
  user prefers reduced motion.
- Auto-scroll-to-bottom is preserved: the virtualized list follows the
  rendered height as the smoothed text grows.

Closes PostHog#2517
@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
packages/ui/src/primitives/hooks/useSmoothText.test.ts:8-31
The `nextRevealLength` cases are all testing a pure function with varying inputs and a single expected output — a textbook fit for `it.each`. The current form repeats the `expect(nextRevealLength(...)).toBe(...)` structure five times and buries the "caught-up-past-target" case as a second assertion inside the first test rather than its own row. Parameterising them would make the contract of the function easier to scan and add new cases to.

```suggestion
describe("nextRevealLength", () => {
  it.each([
    // label                          current  target  elapsedMs  rate  expected
    ["caught up: equal",              10,      10,     16,        120,  10],
    ["caught up: already past",       12,      10,     16,        120,  10],
    ["advances proportionally",        0,     100,    100,        120,  12],
    ["never overshoots",              95,     100,   1000,        120, 100],
    ["always advances at least one",   0,     100,      0,        120,   1],
    ["snaps when lag is too large",    0,    5000,     16,        120, 5000],
  ])("%s", (_, current, target, elapsedMs, rate, expected) => {
    expect(nextRevealLength(current, target, elapsedMs, rate)).toBe(expected);
  });
});
```

Reviews (1): Last reviewed commit: "feat(ui): smooth streaming of agent mess..." | Re-trigger Greptile

Comment on lines +8 to +31
describe("nextRevealLength", () => {
it("returns the target when already caught up", () => {
expect(nextRevealLength(10, 10, 16, 120)).toBe(10);
expect(nextRevealLength(12, 10, 16, 120)).toBe(10);
});

it("advances proportionally to elapsed time and rate", () => {
// 120 chars/sec over 100ms = 12 chars.
expect(nextRevealLength(0, 100, 100, 120)).toBe(12);
});

it("never overshoots the target", () => {
expect(nextRevealLength(95, 100, 1000, 120)).toBe(100);
});

it("always advances at least one char when behind", () => {
// Tiny elapsed time would round to zero; keep forward progress.
expect(nextRevealLength(0, 100, 0, 120)).toBe(1);
});

it("snaps when the lag is too large to ease pleasantly", () => {
expect(nextRevealLength(0, 5000, 16, 120)).toBe(5000);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The nextRevealLength cases are all testing a pure function with varying inputs and a single expected output — a textbook fit for it.each. The current form repeats the expect(nextRevealLength(...)).toBe(...) structure five times and buries the "caught-up-past-target" case as a second assertion inside the first test rather than its own row. Parameterising them would make the contract of the function easier to scan and add new cases to.

Suggested change
describe("nextRevealLength", () => {
it("returns the target when already caught up", () => {
expect(nextRevealLength(10, 10, 16, 120)).toBe(10);
expect(nextRevealLength(12, 10, 16, 120)).toBe(10);
});
it("advances proportionally to elapsed time and rate", () => {
// 120 chars/sec over 100ms = 12 chars.
expect(nextRevealLength(0, 100, 100, 120)).toBe(12);
});
it("never overshoots the target", () => {
expect(nextRevealLength(95, 100, 1000, 120)).toBe(100);
});
it("always advances at least one char when behind", () => {
// Tiny elapsed time would round to zero; keep forward progress.
expect(nextRevealLength(0, 100, 0, 120)).toBe(1);
});
it("snaps when the lag is too large to ease pleasantly", () => {
expect(nextRevealLength(0, 5000, 16, 120)).toBe(5000);
});
});
describe("nextRevealLength", () => {
it.each([
// label current target elapsedMs rate expected
["caught up: equal", 10, 10, 16, 120, 10],
["caught up: already past", 12, 10, 16, 120, 10],
["advances proportionally", 0, 100, 100, 120, 12],
["never overshoots", 95, 100, 1000, 120, 100],
["always advances at least one", 0, 100, 0, 120, 1],
["snaps when lag is too large", 0, 5000, 16, 120, 5000],
])("%s", (_, current, target, elapsedMs, rate, expected) => {
expect(nextRevealLength(current, target, elapsedMs, rate)).toBe(expected);
});
});

Context Used: Do not attempt to comment on incorrect alphabetica... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/ui/src/primitives/hooks/useSmoothText.test.ts
Line: 8-31

Comment:
The `nextRevealLength` cases are all testing a pure function with varying inputs and a single expected output — a textbook fit for `it.each`. The current form repeats the `expect(nextRevealLength(...)).toBe(...)` structure five times and buries the "caught-up-past-target" case as a second assertion inside the first test rather than its own row. Parameterising them would make the contract of the function easier to scan and add new cases to.

```suggestion
describe("nextRevealLength", () => {
  it.each([
    // label                          current  target  elapsedMs  rate  expected
    ["caught up: equal",              10,      10,     16,        120,  10],
    ["caught up: already past",       12,      10,     16,        120,  10],
    ["advances proportionally",        0,     100,    100,        120,  12],
    ["never overshoots",              95,     100,   1000,        120, 100],
    ["always advances at least one",   0,     100,      0,        120,   1],
    ["snaps when lag is too large",    0,    5000,     16,        120, 5000],
  ])("%s", (_, current, target, elapsedMs, rate, expected) => {
    expect(nextRevealLength(current, target, elapsedMs, rate)).toBe(expected);
  });
});
```

**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@charlesvien

Copy link
Copy Markdown
Member

hey @archievi, thanks for this! excited to try it out, can you handle greptile comments and do a second review pass on it?

Address Greptile review: collapse the repeated nextRevealLength
assertions into a single it.each table, and pull the "caught up past
target" case out of the first test into its own row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@archievi

Copy link
Copy Markdown
Author

Thanks @charlesvien! Handled the Greptile comment — nextRevealLength is now a single it.each table (pushed in e0b12a1), and the "caught up past target" case is its own row instead of a buried second assertion. Did a second pass over the diff myself; all 10 tests pass locally. Ready for another look whenever you have a chance. 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Smooth token streaming

2 participants